yonyon 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +189 -0
- package/dist/args.js +110 -0
- package/dist/cli.js +112 -0
- package/dist/commands/ask.js +69 -0
- package/dist/commands/book.js +52 -0
- package/dist/commands/docs.js +58 -0
- package/dist/commands/mcp.js +60 -0
- package/dist/commands/projects.js +46 -0
- package/dist/errors.js +19 -0
- package/dist/format.js +134 -0
- package/dist/http.js +175 -0
- package/dist/index.js +3 -0
- package/dist/io.js +9 -0
- package/package.json +56 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yonatan Gross
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# yonyon
|
|
2
|
+
|
|
3
|
+
Command line client for the public [yonyon.ai](https://yonyon.ai) agent API.
|
|
4
|
+
|
|
5
|
+
Ask the site's agent a question, list the portfolio projects, wire up the MCP server, or grab the
|
|
6
|
+
booking link, without writing an HTTP integration first.
|
|
7
|
+
|
|
8
|
+
The API is public and unauthenticated. There is no key to set, no config file to write, and no login
|
|
9
|
+
step. The only constraint is rate: **10 requests per IP per minute**, shared across every endpoint.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
Run it without installing anything:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npx yonyon ask "What has Yonatan built with MCP?"
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Or install it globally:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm install -g yonyon
|
|
23
|
+
yonyon --help
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Requires Node.js 24 or newer.
|
|
27
|
+
|
|
28
|
+
## Commands
|
|
29
|
+
|
|
30
|
+
### `yonyon ask "<question>"`
|
|
31
|
+
|
|
32
|
+
Ask the agent about Yonatan Gross, his projects, or his services. The default path is `POST /ask`,
|
|
33
|
+
the single-shot endpoint built for server-side callers.
|
|
34
|
+
|
|
35
|
+
```console
|
|
36
|
+
$ yonyon ask "What is OrchestKit?"
|
|
37
|
+
OrchestKit is an open-source Claude Code agent framework with 100+ skills,
|
|
38
|
+
35+ agents, and 200+ hooks.
|
|
39
|
+
|
|
40
|
+
Follow-ups you could ask:
|
|
41
|
+
What else has he built?
|
|
42
|
+
Is he available for consulting?
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Add `--stream` to use `POST /api/chat` instead and print tokens as they arrive. Add `--json` to get
|
|
46
|
+
the raw `/ask` response body.
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
yonyon ask "Is he available for consulting?" --stream
|
|
50
|
+
yonyon ask "List his projects" --json
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### `yonyon projects [--limit N] [--json]`
|
|
54
|
+
|
|
55
|
+
List the portfolio projects from `GET /api/v1/projects`.
|
|
56
|
+
|
|
57
|
+
```console
|
|
58
|
+
$ yonyon projects --limit 2
|
|
59
|
+
NAME DESCRIPTION URL
|
|
60
|
+
-------------------- --------------------------------------------- ------------------------------------------------
|
|
61
|
+
OrchestKit Open-source Claude Code agent framework. https://yonyon.ai/projects/orchestkit
|
|
62
|
+
AI Operations Platfo Production WhatsApp AI (RAG), commerce, cont… https://yonyon.ai/projects/ai-operations-platform
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`--limit` accepts 1 to 50, the range the API itself enforces. Use `--json` for the raw body, which
|
|
66
|
+
also carries `total` and `nextCursor` for pagination.
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
yonyon projects --json
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### `yonyon mcp [--tools] [--json]`
|
|
73
|
+
|
|
74
|
+
Print ready-to-paste MCP client configuration for the yonyon.ai MCP server, a JSON-RPC 2.0
|
|
75
|
+
Streamable HTTP endpoint that needs no auth.
|
|
76
|
+
|
|
77
|
+
```console
|
|
78
|
+
$ yonyon mcp
|
|
79
|
+
MCP endpoint: https://yonyon.ai/api/mcp
|
|
80
|
+
Transport: Streamable HTTP. Authentication: none.
|
|
81
|
+
|
|
82
|
+
Add it to Claude Code:
|
|
83
|
+
claude mcp add --transport http yonyon https://yonyon.ai/api/mcp
|
|
84
|
+
|
|
85
|
+
Or add this to .mcp.json:
|
|
86
|
+
{
|
|
87
|
+
"mcpServers": {
|
|
88
|
+
"yonyon": {
|
|
89
|
+
"type": "http",
|
|
90
|
+
"url": "https://yonyon.ai/api/mcp"
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`--tools` queries `tools/list` live and prints each tool with its description. `--json` prints only
|
|
97
|
+
the config object, so it can be redirected into a file.
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
yonyon mcp --tools
|
|
101
|
+
yonyon mcp --json > .mcp.json
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### `yonyon book [--no-open]`
|
|
105
|
+
|
|
106
|
+
Print the link for a free 15-minute intro call, and open it in the default browser.
|
|
107
|
+
|
|
108
|
+
```console
|
|
109
|
+
$ yonyon book
|
|
110
|
+
Book a free 15-minute intro call: https://cal.com/yonyon
|
|
111
|
+
Opening it in your browser.
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
The browser is only opened when stdout is a terminal, so piping the command never launches anything.
|
|
115
|
+
Pass `--no-open` to suppress it explicitly.
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
yonyon book --no-open
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### `yonyon docs [--json]`
|
|
122
|
+
|
|
123
|
+
Print the discovery map: every machine-readable document an agent can read to learn what the site
|
|
124
|
+
offers and how to call it.
|
|
125
|
+
|
|
126
|
+
```console
|
|
127
|
+
$ yonyon docs
|
|
128
|
+
Discovery documents
|
|
129
|
+
URL WHAT IT IS
|
|
130
|
+
-------------------------------------------------- ------------------------------------------------
|
|
131
|
+
https://yonyon.ai/llms.txt Short site summary written for LLMs
|
|
132
|
+
https://yonyon.ai/llms-full.txt Full bio, projects, services and FAQ
|
|
133
|
+
https://yonyon.ai/developers Human-readable API and MCP documentation
|
|
134
|
+
...
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
yonyon docs --json
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## Global options
|
|
142
|
+
|
|
143
|
+
| Option | Meaning |
|
|
144
|
+
| ------------------ | ---------------------------------------------------------------------------------------------------------- |
|
|
145
|
+
| `-h`, `--help` | Show help. Works per command too: `yonyon ask --help`. |
|
|
146
|
+
| `-v`, `--version` | Print the CLI version. |
|
|
147
|
+
| `--base-url <url>` | Target another deployment. Defaults to `https://yonyon.ai`, or the `YONYON_BASE_URL` environment variable. |
|
|
148
|
+
| `--timeout <ms>` | Per-request timeout. Defaults to 60000 for `ask` and 15000 elsewhere. |
|
|
149
|
+
| `--json` | Machine-readable output, on the commands that support it. |
|
|
150
|
+
|
|
151
|
+
Point it at a local dev server:
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
yonyon projects --base-url https://portfolio.localhost
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## Exit codes
|
|
158
|
+
|
|
159
|
+
| Code | Meaning |
|
|
160
|
+
| ---- | ------------------------------------------------------------------------- |
|
|
161
|
+
| `0` | Success. |
|
|
162
|
+
| `1` | The request failed: network error, timeout, non-2xx response, rate limit. |
|
|
163
|
+
| `2` | A usage mistake: unknown command, unknown flag, missing or invalid value. |
|
|
164
|
+
|
|
165
|
+
Rate limiting gets its own message rather than a bare HTTP 429:
|
|
166
|
+
|
|
167
|
+
```console
|
|
168
|
+
$ yonyon ask "hello"
|
|
169
|
+
yonyon: Rate limited by yonyon.ai (10 requests per IP per minute). Retry in 43s.
|
|
170
|
+
The public API is unauthenticated, so the budget is shared per source IP.
|
|
171
|
+
Wait for the window to reset and run the command again.
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
## Development
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
pnpm install
|
|
178
|
+
pnpm test # vitest, network fully mocked
|
|
179
|
+
pnpm exec tsc --noEmit
|
|
180
|
+
pnpm run build # emits dist/
|
|
181
|
+
node dist/index.js --help
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
The CLI has zero runtime dependencies. Argument parsing is hand-rolled, which is smaller than any
|
|
185
|
+
parser library at this command count.
|
|
186
|
+
|
|
187
|
+
## License
|
|
188
|
+
|
|
189
|
+
MIT. See [LICENSE](./LICENSE).
|
package/dist/args.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { usageError } from "./errors.js";
|
|
2
|
+
/** Flags that take a value. Everything else is a boolean switch. */
|
|
3
|
+
const VALUE_FLAGS = new Set(["base-url", "limit", "timeout"]);
|
|
4
|
+
const ALIASES = {
|
|
5
|
+
h: "help",
|
|
6
|
+
v: "version",
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Hand-rolled parser. The CLI has five commands and eight flags, which is well
|
|
10
|
+
* under the size where a dependency pays for itself.
|
|
11
|
+
*
|
|
12
|
+
* Supports `--flag`, `--flag=value`, `--flag value`, `--no-flag`, `-h`, `-v`,
|
|
13
|
+
* and `--` to stop flag parsing. Bare `-` and unknown short clusters are
|
|
14
|
+
* treated as positionals so a question can start with a dash.
|
|
15
|
+
*/
|
|
16
|
+
export function parseArgs(argv) {
|
|
17
|
+
const positionals = [];
|
|
18
|
+
const flags = new Map();
|
|
19
|
+
let sawTerminator = false;
|
|
20
|
+
for (let i = 0; i < argv.length; i++) {
|
|
21
|
+
const token = argv[i];
|
|
22
|
+
if (sawTerminator) {
|
|
23
|
+
positionals.push(token);
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (token === "--") {
|
|
27
|
+
sawTerminator = true;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (token.startsWith("--")) {
|
|
31
|
+
const body = token.slice(2);
|
|
32
|
+
if (body === "")
|
|
33
|
+
throw usageError("Empty flag name.");
|
|
34
|
+
const eq = body.indexOf("=");
|
|
35
|
+
if (eq !== -1) {
|
|
36
|
+
const name = body.slice(0, eq);
|
|
37
|
+
const value = body.slice(eq + 1);
|
|
38
|
+
if (!VALUE_FLAGS.has(name)) {
|
|
39
|
+
throw usageError(`Flag --${name} does not take a value.`);
|
|
40
|
+
}
|
|
41
|
+
flags.set(name, value);
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (body.startsWith("no-")) {
|
|
45
|
+
flags.set(body.slice(3), false);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (VALUE_FLAGS.has(body)) {
|
|
49
|
+
const next = argv[i + 1];
|
|
50
|
+
if (next === undefined || next.startsWith("--")) {
|
|
51
|
+
throw usageError(`Flag --${body} needs a value.`);
|
|
52
|
+
}
|
|
53
|
+
flags.set(body, next);
|
|
54
|
+
i++;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
flags.set(body, true);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
// Short flags: only the documented single-letter aliases, so that a
|
|
61
|
+
// negative number or a dash-led question stays a positional.
|
|
62
|
+
if (token.length === 2 && token.startsWith("-") && ALIASES[token[1]]) {
|
|
63
|
+
flags.set(ALIASES[token[1]], true);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
positionals.push(token);
|
|
67
|
+
}
|
|
68
|
+
const command = positionals.shift() ?? "";
|
|
69
|
+
return { command, positionals, flags };
|
|
70
|
+
}
|
|
71
|
+
/** Reject any flag the command does not understand, instead of ignoring it. */
|
|
72
|
+
export function assertKnownFlags(flags, allowed, command) {
|
|
73
|
+
const known = new Set([...allowed, "help", "version", "base-url", "timeout"]);
|
|
74
|
+
for (const name of flags.keys()) {
|
|
75
|
+
if (!known.has(name)) {
|
|
76
|
+
throw usageError(`Unknown flag --${name} for "yonyon ${command}".`, [
|
|
77
|
+
`Run "yonyon ${command} --help" to see the supported flags.`,
|
|
78
|
+
]);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
export function boolFlag(flags, name) {
|
|
83
|
+
return flags.get(name) === true;
|
|
84
|
+
}
|
|
85
|
+
/** True unless the flag was explicitly negated with `--no-<name>`. */
|
|
86
|
+
export function enabledUnlessNegated(flags, name) {
|
|
87
|
+
return flags.get(name) !== false;
|
|
88
|
+
}
|
|
89
|
+
export function stringFlag(flags, name) {
|
|
90
|
+
const value = flags.get(name);
|
|
91
|
+
if (value === undefined)
|
|
92
|
+
return undefined;
|
|
93
|
+
if (typeof value !== "string") {
|
|
94
|
+
throw usageError(`Flag --${name} needs a value.`);
|
|
95
|
+
}
|
|
96
|
+
return value;
|
|
97
|
+
}
|
|
98
|
+
export function intFlag(flags, name, range) {
|
|
99
|
+
const raw = stringFlag(flags, name);
|
|
100
|
+
if (raw === undefined)
|
|
101
|
+
return undefined;
|
|
102
|
+
if (!/^\d+$/.test(raw)) {
|
|
103
|
+
throw usageError(`--${name} must be a whole number, got "${raw}".`);
|
|
104
|
+
}
|
|
105
|
+
const value = Number(raw);
|
|
106
|
+
if (value < range.min || value > range.max) {
|
|
107
|
+
throw usageError(`--${name} must be between ${range.min} and ${range.max}, got ${value}.`);
|
|
108
|
+
}
|
|
109
|
+
return value;
|
|
110
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { assertKnownFlags, boolFlag, intFlag, parseArgs, stringFlag } from "./args.js";
|
|
3
|
+
import { askCommand, ASK_HELP } from "./commands/ask.js";
|
|
4
|
+
import { bookCommand, BOOK_HELP } from "./commands/book.js";
|
|
5
|
+
import { docsCommand, DOCS_HELP } from "./commands/docs.js";
|
|
6
|
+
import { mcpCommand, MCP_HELP } from "./commands/mcp.js";
|
|
7
|
+
import { projectsCommand, PROJECTS_HELP } from "./commands/projects.js";
|
|
8
|
+
import { CliError, usageError } from "./errors.js";
|
|
9
|
+
import { FAST_TIMEOUT_MS, resolveBaseUrl, SLOW_TIMEOUT_MS } from "./http.js";
|
|
10
|
+
import { line, processIo } from "./io.js";
|
|
11
|
+
const require = createRequire(import.meta.url);
|
|
12
|
+
export function version() {
|
|
13
|
+
const pkg = require("../package.json");
|
|
14
|
+
return pkg.version ?? "0.0.0";
|
|
15
|
+
}
|
|
16
|
+
const HELP = `yonyon - command line client for the public yonyon.ai agent API.
|
|
17
|
+
|
|
18
|
+
Usage: yonyon <command> [options]
|
|
19
|
+
|
|
20
|
+
Commands:
|
|
21
|
+
ask "<question>" Ask the agent about Yonatan, his projects or his services
|
|
22
|
+
projects List the portfolio projects
|
|
23
|
+
mcp Print MCP client config, or list the server's tools
|
|
24
|
+
book Print (and open) the intro-call booking link
|
|
25
|
+
docs Print the machine-readable discovery map
|
|
26
|
+
|
|
27
|
+
Global options:
|
|
28
|
+
-h, --help Show help. Works per command too: yonyon ask --help
|
|
29
|
+
-v, --version Print the CLI version
|
|
30
|
+
--base-url <url> Target another deployment (default: https://yonyon.ai,
|
|
31
|
+
or the YONYON_BASE_URL environment variable)
|
|
32
|
+
--timeout <ms> Per-request timeout in milliseconds
|
|
33
|
+
--json Machine-readable output, where the command supports it
|
|
34
|
+
|
|
35
|
+
The API is public and unauthenticated. There is nothing to configure and no
|
|
36
|
+
key to set. It is rate limited to 10 requests per IP per minute.
|
|
37
|
+
|
|
38
|
+
Examples:
|
|
39
|
+
yonyon ask "What has Yonatan built with MCP?"
|
|
40
|
+
yonyon projects --limit 3
|
|
41
|
+
yonyon mcp --tools
|
|
42
|
+
yonyon docs --json`;
|
|
43
|
+
const COMMAND_HELP = {
|
|
44
|
+
ask: ASK_HELP,
|
|
45
|
+
projects: PROJECTS_HELP,
|
|
46
|
+
mcp: MCP_HELP,
|
|
47
|
+
book: BOOK_HELP,
|
|
48
|
+
docs: DOCS_HELP,
|
|
49
|
+
};
|
|
50
|
+
/** Commands whose backend is an LLM get the roomier timeout. */
|
|
51
|
+
const SLOW_COMMANDS = new Set(["ask"]);
|
|
52
|
+
export async function run(argv, io = processIo) {
|
|
53
|
+
try {
|
|
54
|
+
const { command, positionals, flags } = parseArgs(argv);
|
|
55
|
+
if (boolFlag(flags, "version") && !command) {
|
|
56
|
+
line(io, version());
|
|
57
|
+
return 0;
|
|
58
|
+
}
|
|
59
|
+
if (!command) {
|
|
60
|
+
line(io, HELP);
|
|
61
|
+
return boolFlag(flags, "help") ? 0 : 1;
|
|
62
|
+
}
|
|
63
|
+
const help = COMMAND_HELP[command];
|
|
64
|
+
if (help === undefined) {
|
|
65
|
+
throw usageError(`Unknown command "${command}".`, [
|
|
66
|
+
`Known commands: ${Object.keys(COMMAND_HELP).join(", ")}.`,
|
|
67
|
+
'Run "yonyon --help" for usage.',
|
|
68
|
+
]);
|
|
69
|
+
}
|
|
70
|
+
if (boolFlag(flags, "help")) {
|
|
71
|
+
line(io, help);
|
|
72
|
+
return 0;
|
|
73
|
+
}
|
|
74
|
+
const baseUrl = resolveBaseUrl(stringFlag(flags, "base-url"));
|
|
75
|
+
const timeoutMs = intFlag(flags, "timeout", { min: 1, max: 600_000 }) ??
|
|
76
|
+
(SLOW_COMMANDS.has(command) ? SLOW_TIMEOUT_MS : FAST_TIMEOUT_MS);
|
|
77
|
+
const ctx = { baseUrl, timeoutMs };
|
|
78
|
+
switch (command) {
|
|
79
|
+
case "ask":
|
|
80
|
+
await askCommand(positionals, flags, ctx, io);
|
|
81
|
+
break;
|
|
82
|
+
case "projects":
|
|
83
|
+
await projectsCommand(flags, ctx, io);
|
|
84
|
+
break;
|
|
85
|
+
case "mcp":
|
|
86
|
+
await mcpCommand(flags, ctx, io);
|
|
87
|
+
break;
|
|
88
|
+
case "book":
|
|
89
|
+
assertKnownFlags(flags, ["open", "json"], "book");
|
|
90
|
+
bookCommand(flags, io);
|
|
91
|
+
break;
|
|
92
|
+
case "docs":
|
|
93
|
+
docsCommand(flags, baseUrl, io);
|
|
94
|
+
break;
|
|
95
|
+
/* c8 ignore next 2 */
|
|
96
|
+
default:
|
|
97
|
+
throw usageError(`Unknown command "${command}".`);
|
|
98
|
+
}
|
|
99
|
+
return 0;
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
if (error instanceof CliError) {
|
|
103
|
+
io.err(`yonyon: ${error.message}\n`);
|
|
104
|
+
for (const hint of error.hints)
|
|
105
|
+
io.err(` ${hint}\n`);
|
|
106
|
+
return error.exitCode;
|
|
107
|
+
}
|
|
108
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
109
|
+
io.err(`yonyon: unexpected error: ${message}\n`);
|
|
110
|
+
return 1;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { assertKnownFlags, boolFlag } from "../args.js";
|
|
2
|
+
import { usageError } from "../errors.js";
|
|
3
|
+
import { createTrailerStripper, splitFollowUps } from "../format.js";
|
|
4
|
+
import { postJson, postStream } from "../http.js";
|
|
5
|
+
import { line } from "../io.js";
|
|
6
|
+
export const ASK_HELP = `Usage: yonyon ask "<question>" [options]
|
|
7
|
+
|
|
8
|
+
Ask the yonyon.ai agent a question about Yonatan Gross, his projects, or his
|
|
9
|
+
services. Answers come from POST /ask, the single-shot endpoint built for
|
|
10
|
+
server-side callers.
|
|
11
|
+
|
|
12
|
+
Options:
|
|
13
|
+
--stream Use the multi-turn POST /api/chat endpoint and print
|
|
14
|
+
tokens as they arrive, instead of one-shot /ask.
|
|
15
|
+
--json Print the raw JSON response. Ignored with --stream,
|
|
16
|
+
which has no JSON representation.
|
|
17
|
+
--base-url <url> Point at another deployment (default: https://yonyon.ai).
|
|
18
|
+
--timeout <ms> Per-request timeout (default: 60000).
|
|
19
|
+
|
|
20
|
+
Examples:
|
|
21
|
+
yonyon ask "What has Yonatan built with MCP?"
|
|
22
|
+
yonyon ask "Is he available for consulting?" --stream
|
|
23
|
+
yonyon ask "List his projects" --json`;
|
|
24
|
+
export async function askCommand(positionals, flags, ctx, io) {
|
|
25
|
+
assertKnownFlags(flags, ["stream", "json"], "ask");
|
|
26
|
+
const question = positionals.join(" ").trim();
|
|
27
|
+
if (!question) {
|
|
28
|
+
throw usageError('yonyon ask needs a question, for example: yonyon ask "What is OrchestKit?"');
|
|
29
|
+
}
|
|
30
|
+
if (boolFlag(flags, "stream")) {
|
|
31
|
+
// The streamed reply carries the same FOLLOW_UPS trailer as /ask, so strip
|
|
32
|
+
// it on the way through rather than printing a marker meant for the web UI.
|
|
33
|
+
const stripper = createTrailerStripper();
|
|
34
|
+
let printed = "";
|
|
35
|
+
await postStream(ctx, "/api/chat", { messages: [{ role: "user", content: question }] }, (chunk) => {
|
|
36
|
+
const text = stripper.push(chunk);
|
|
37
|
+
if (text) {
|
|
38
|
+
printed += text;
|
|
39
|
+
io.out(text);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
const tail = stripper.flush();
|
|
43
|
+
if (tail) {
|
|
44
|
+
printed += tail;
|
|
45
|
+
io.out(tail);
|
|
46
|
+
}
|
|
47
|
+
if (!printed.endsWith("\n"))
|
|
48
|
+
io.out("\n");
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const response = await postJson(ctx, "/ask", { query: question });
|
|
52
|
+
if (boolFlag(flags, "json")) {
|
|
53
|
+
line(io, JSON.stringify(response, null, 2));
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const answer = typeof response.answer === "string" ? response.answer : "";
|
|
57
|
+
if (!answer) {
|
|
58
|
+
line(io, "The API returned no answer for that question.");
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const { text, followUps } = splitFollowUps(answer);
|
|
62
|
+
line(io, text);
|
|
63
|
+
if (followUps.length > 0) {
|
|
64
|
+
line(io);
|
|
65
|
+
line(io, "Follow-ups you could ask:");
|
|
66
|
+
for (const followUp of followUps)
|
|
67
|
+
line(io, ` ${followUp}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { assertKnownFlags, boolFlag, enabledUnlessNegated } from "../args.js";
|
|
3
|
+
import { line } from "../io.js";
|
|
4
|
+
export const BOOKING_URL = "https://cal.com/yonyon";
|
|
5
|
+
export const BOOK_HELP = `Usage: yonyon book [options]
|
|
6
|
+
|
|
7
|
+
Print the booking link for a free 15-minute intro call, and open it in the
|
|
8
|
+
default browser when stdout is a terminal.
|
|
9
|
+
|
|
10
|
+
Options:
|
|
11
|
+
--no-open Print the URL without opening a browser. Opening is also
|
|
12
|
+
skipped automatically when stdout is not a TTY.
|
|
13
|
+
--json Print the booking URL as JSON.
|
|
14
|
+
|
|
15
|
+
Examples:
|
|
16
|
+
yonyon book
|
|
17
|
+
yonyon book --no-open`;
|
|
18
|
+
/** Platform-appropriate opener. Detached and unref'd so we never block exit. */
|
|
19
|
+
export function openInBrowser(url) {
|
|
20
|
+
const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
21
|
+
const args = process.platform === "win32" ? ["", url] : [url];
|
|
22
|
+
try {
|
|
23
|
+
const child = spawn(command, args, {
|
|
24
|
+
stdio: "ignore",
|
|
25
|
+
detached: true,
|
|
26
|
+
shell: process.platform === "win32",
|
|
27
|
+
});
|
|
28
|
+
// A missing opener must not crash the command; the URL is already printed.
|
|
29
|
+
child.on("error", () => { });
|
|
30
|
+
child.unref();
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// Same reasoning: printing the URL is the contract, opening is a bonus.
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export function bookCommand(flags, io, open = openInBrowser) {
|
|
37
|
+
assertKnownFlags(flags, ["open", "json"], "book");
|
|
38
|
+
if (boolFlag(flags, "json")) {
|
|
39
|
+
line(io, JSON.stringify({ bookingUrl: BOOKING_URL, durationMinutes: 15, price: "free" }, null, 2));
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
line(io, `Book a free 15-minute intro call: ${BOOKING_URL}`);
|
|
43
|
+
const wanted = enabledUnlessNegated(flags, "open");
|
|
44
|
+
if (!wanted)
|
|
45
|
+
return;
|
|
46
|
+
if (!io.isTty) {
|
|
47
|
+
line(io, "(Not a terminal, so the browser was not opened. Use the link above.)");
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
open(BOOKING_URL);
|
|
51
|
+
line(io, "Opening it in your browser.");
|
|
52
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { assertKnownFlags, boolFlag } from "../args.js";
|
|
2
|
+
import { table } from "../format.js";
|
|
3
|
+
import { line } from "../io.js";
|
|
4
|
+
export const DOCS_HELP = `Usage: yonyon docs [options]
|
|
5
|
+
|
|
6
|
+
Print the discovery map: every machine-readable document an agent can read to
|
|
7
|
+
learn what yonyon.ai offers and how to call it.
|
|
8
|
+
|
|
9
|
+
Options:
|
|
10
|
+
--json Print the map as JSON.
|
|
11
|
+
--base-url <url> Point at another deployment (default: https://yonyon.ai).
|
|
12
|
+
|
|
13
|
+
Examples:
|
|
14
|
+
yonyon docs
|
|
15
|
+
yonyon docs --json`;
|
|
16
|
+
/** Every path here was verified live against https://yonyon.ai on 2026-08-21. */
|
|
17
|
+
const ENTRIES = [
|
|
18
|
+
{ path: "/llms.txt", what: "Short site summary written for LLMs" },
|
|
19
|
+
{ path: "/llms-full.txt", what: "Full bio, projects, services and FAQ" },
|
|
20
|
+
{ path: "/developers", what: "Human-readable API and MCP documentation" },
|
|
21
|
+
{ path: "/developers.md", what: "The same developer docs as markdown" },
|
|
22
|
+
{ path: "/.well-known/openapi.json", what: "OpenAPI 3.1 spec for the public HTTP API" },
|
|
23
|
+
{ path: "/.well-known/agent-skills/index.json", what: "Agent skills index" },
|
|
24
|
+
{ path: "/.well-known/mcp.json", what: "MCP server descriptor" },
|
|
25
|
+
{ path: "/auth.md", what: "Authentication notes (there is none, by design)" },
|
|
26
|
+
];
|
|
27
|
+
const ENDPOINTS = [
|
|
28
|
+
{ path: "/ask", what: 'POST {"query":"..."} for a single-shot JSON answer' },
|
|
29
|
+
{ path: "/api/chat", what: 'POST {"messages":[...]} for a streaming reply' },
|
|
30
|
+
{ path: "/api/v1/projects", what: "GET the portfolio projects, cursor paginated" },
|
|
31
|
+
{ path: "/api/mcp", what: "JSON-RPC 2.0 Streamable HTTP MCP server" },
|
|
32
|
+
];
|
|
33
|
+
export function docsCommand(flags, baseUrl, io) {
|
|
34
|
+
assertKnownFlags(flags, ["json"], "docs");
|
|
35
|
+
const absolute = (entries) => entries.map((entry) => ({ url: `${baseUrl}${entry.path}`, what: entry.what }));
|
|
36
|
+
if (boolFlag(flags, "json")) {
|
|
37
|
+
line(io, JSON.stringify({
|
|
38
|
+
baseUrl,
|
|
39
|
+
documents: absolute(ENTRIES),
|
|
40
|
+
endpoints: absolute(ENDPOINTS),
|
|
41
|
+
booking: "https://cal.com/yonyon",
|
|
42
|
+
rateLimit: "10 requests per IP per minute, unauthenticated",
|
|
43
|
+
}, null, 2));
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
line(io, "Discovery documents");
|
|
47
|
+
line(io, table(["URL", "WHAT IT IS"], absolute(ENTRIES).map((e) => [e.url, e.what]), {
|
|
48
|
+
shrinkable: [1],
|
|
49
|
+
}));
|
|
50
|
+
line(io);
|
|
51
|
+
line(io, "Callable endpoints");
|
|
52
|
+
line(io, table(["URL", "HOW TO CALL IT"], absolute(ENDPOINTS).map((e) => [e.url, e.what]), {
|
|
53
|
+
shrinkable: [1],
|
|
54
|
+
}));
|
|
55
|
+
line(io);
|
|
56
|
+
line(io, "Booking: https://cal.com/yonyon");
|
|
57
|
+
line(io, "Rate limit: 10 requests per IP per minute. No API key, no OAuth.");
|
|
58
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { assertKnownFlags, boolFlag } from "../args.js";
|
|
2
|
+
import { postJson } from "../http.js";
|
|
3
|
+
import { line } from "../io.js";
|
|
4
|
+
export const MCP_HELP = `Usage: yonyon mcp [options]
|
|
5
|
+
|
|
6
|
+
Print ready-to-paste client configuration for the yonyon.ai MCP server, a
|
|
7
|
+
JSON-RPC 2.0 Streamable HTTP endpoint that needs no auth.
|
|
8
|
+
|
|
9
|
+
Options:
|
|
10
|
+
--tools Also query tools/list live and print each tool name
|
|
11
|
+
with its description.
|
|
12
|
+
--json Print only the .mcp.json block, for piping into a file.
|
|
13
|
+
--base-url <url> Point at another deployment (default: https://yonyon.ai).
|
|
14
|
+
--timeout <ms> Per-request timeout (default: 15000).
|
|
15
|
+
|
|
16
|
+
Examples:
|
|
17
|
+
yonyon mcp
|
|
18
|
+
yonyon mcp --tools
|
|
19
|
+
yonyon mcp --json > .mcp.json`;
|
|
20
|
+
function configBlock(endpoint) {
|
|
21
|
+
return JSON.stringify({ mcpServers: { yonyon: { type: "http", url: endpoint } } }, null, 2);
|
|
22
|
+
}
|
|
23
|
+
export async function mcpCommand(flags, ctx, io) {
|
|
24
|
+
assertKnownFlags(flags, ["tools", "json"], "mcp");
|
|
25
|
+
const endpoint = `${ctx.baseUrl}/api/mcp`;
|
|
26
|
+
if (boolFlag(flags, "json")) {
|
|
27
|
+
line(io, configBlock(endpoint));
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
line(io, `MCP endpoint: ${endpoint}`);
|
|
31
|
+
line(io, "Transport: Streamable HTTP. Authentication: none.");
|
|
32
|
+
line(io);
|
|
33
|
+
line(io, "Add it to Claude Code:");
|
|
34
|
+
line(io, ` claude mcp add --transport http yonyon ${endpoint}`);
|
|
35
|
+
line(io);
|
|
36
|
+
line(io, "Or add this to .mcp.json:");
|
|
37
|
+
line(io, configBlock(endpoint));
|
|
38
|
+
if (!boolFlag(flags, "tools")) {
|
|
39
|
+
line(io);
|
|
40
|
+
line(io, "Run 'yonyon mcp --tools' to list the tools the server exposes.");
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const response = await postJson(ctx, "/api/mcp", { jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }, { accept: "application/json, text/event-stream" });
|
|
44
|
+
line(io);
|
|
45
|
+
if (response.error) {
|
|
46
|
+
line(io, `Tools unavailable: ${response.error.message ?? "the server returned a JSON-RPC error."}`);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const tools = response.result?.tools ?? [];
|
|
50
|
+
if (tools.length === 0) {
|
|
51
|
+
line(io, "The server reported no tools.");
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
line(io, `Tools (${tools.length}):`);
|
|
55
|
+
for (const tool of tools) {
|
|
56
|
+
line(io, ` ${tool.name ?? "(unnamed)"}`);
|
|
57
|
+
if (tool.description)
|
|
58
|
+
line(io, ` ${tool.description}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { assertKnownFlags, boolFlag, intFlag } from "../args.js";
|
|
2
|
+
import { table, truncate } from "../format.js";
|
|
3
|
+
import { getJson } from "../http.js";
|
|
4
|
+
import { line } from "../io.js";
|
|
5
|
+
/** The API rejects anything outside this range with HTTP 400 invalid_limit. */
|
|
6
|
+
const LIMIT_RANGE = { min: 1, max: 50 };
|
|
7
|
+
export const PROJECTS_HELP = `Usage: yonyon projects [options]
|
|
8
|
+
|
|
9
|
+
List the portfolio projects published at GET /api/v1/projects.
|
|
10
|
+
|
|
11
|
+
Options:
|
|
12
|
+
--limit <n> Return at most n projects (1-50). Default: all.
|
|
13
|
+
--json Print the raw JSON response, including nextCursor.
|
|
14
|
+
--base-url <url> Point at another deployment (default: https://yonyon.ai).
|
|
15
|
+
--timeout <ms> Per-request timeout (default: 15000).
|
|
16
|
+
|
|
17
|
+
Examples:
|
|
18
|
+
yonyon projects
|
|
19
|
+
yonyon projects --limit 2
|
|
20
|
+
yonyon projects --json`;
|
|
21
|
+
export async function projectsCommand(flags, ctx, io) {
|
|
22
|
+
assertKnownFlags(flags, ["limit", "json"], "projects");
|
|
23
|
+
const limit = intFlag(flags, "limit", LIMIT_RANGE);
|
|
24
|
+
const query = limit === undefined ? "" : `?limit=${limit}`;
|
|
25
|
+
const response = await getJson(ctx, `/api/v1/projects${query}`);
|
|
26
|
+
if (boolFlag(flags, "json")) {
|
|
27
|
+
line(io, JSON.stringify(response, null, 2));
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const projects = response.projects ?? [];
|
|
31
|
+
if (projects.length === 0) {
|
|
32
|
+
line(io, "No projects returned.");
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const rows = projects.map((project) => [
|
|
36
|
+
project.name ?? "",
|
|
37
|
+
truncate(project.desc ?? "", 60),
|
|
38
|
+
project.url ?? "",
|
|
39
|
+
]);
|
|
40
|
+
line(io, table(["NAME", "DESCRIPTION", "URL"], rows, { shrinkable: [1] }));
|
|
41
|
+
const total = response.total;
|
|
42
|
+
if (typeof total === "number" && projects.length < total) {
|
|
43
|
+
line(io);
|
|
44
|
+
line(io, `Showing ${projects.length} of ${total}. Use --limit or --json for the cursor.`);
|
|
45
|
+
}
|
|
46
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every failure the CLI reports on purpose. `exitCode` 2 means "you typed
|
|
3
|
+
* something wrong", 1 means "the request or the network failed".
|
|
4
|
+
*/
|
|
5
|
+
export class CliError extends Error {
|
|
6
|
+
exitCode;
|
|
7
|
+
/** Extra lines printed under the message, for hints the user can act on. */
|
|
8
|
+
hints;
|
|
9
|
+
constructor(message, options = {}) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = "CliError";
|
|
12
|
+
this.exitCode = options.exitCode ?? 1;
|
|
13
|
+
this.hints = options.hints ?? [];
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/** A usage mistake: bad flag, missing argument, unknown command. */
|
|
17
|
+
export function usageError(message, hints = []) {
|
|
18
|
+
return new CliError(message, { exitCode: 2, hints });
|
|
19
|
+
}
|
package/dist/format.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/** Column widths are derived from the data, then clamped to the terminal. */
|
|
2
|
+
const MIN_COLUMN = 8;
|
|
3
|
+
export function terminalWidth(fallback = 100) {
|
|
4
|
+
const columns = process.stdout.columns;
|
|
5
|
+
return typeof columns === "number" && columns > 20 ? columns : fallback;
|
|
6
|
+
}
|
|
7
|
+
export function truncate(value, max) {
|
|
8
|
+
if (max <= 1)
|
|
9
|
+
return value.slice(0, Math.max(0, max));
|
|
10
|
+
return value.length <= max ? value : `${value.slice(0, max - 1)}…`;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Render rows as a plain aligned table. No box drawing: the output is meant to
|
|
14
|
+
* survive a pipe into grep or awk.
|
|
15
|
+
*
|
|
16
|
+
* `shrinkable` names the columns that may be truncated when the line does not
|
|
17
|
+
* fit the terminal. Columns left out are printed whole, which is what a URL
|
|
18
|
+
* column needs: a truncated link is worse than a wrapped one.
|
|
19
|
+
*/
|
|
20
|
+
export function table(headers, rows, options = {}) {
|
|
21
|
+
if (rows.length === 0)
|
|
22
|
+
return "";
|
|
23
|
+
const widths = headers.map((header, index) => Math.max(header.length, ...rows.map((row) => (row[index] ?? "").length)));
|
|
24
|
+
const shrinkable = options.shrinkable ?? headers.map((_, index) => index);
|
|
25
|
+
// Shrink the widest shrinkable column until the whole line fits the terminal.
|
|
26
|
+
const gutter = 2;
|
|
27
|
+
const budget = terminalWidth() - gutter * (headers.length - 1);
|
|
28
|
+
let total = widths.reduce((sum, width) => sum + width, 0);
|
|
29
|
+
while (total > budget) {
|
|
30
|
+
let widest = -1;
|
|
31
|
+
for (const index of shrinkable) {
|
|
32
|
+
if (widths[index] <= MIN_COLUMN)
|
|
33
|
+
continue;
|
|
34
|
+
if (widest === -1 || widths[index] > widths[widest])
|
|
35
|
+
widest = index;
|
|
36
|
+
}
|
|
37
|
+
if (widest === -1)
|
|
38
|
+
break;
|
|
39
|
+
widths[widest] = widths[widest] - 1;
|
|
40
|
+
total -= 1;
|
|
41
|
+
}
|
|
42
|
+
const line = (cells) => cells
|
|
43
|
+
.map((cell, index) => truncate(cell, widths[index]).padEnd(widths[index]))
|
|
44
|
+
.join(" ".repeat(gutter))
|
|
45
|
+
.trimEnd();
|
|
46
|
+
return [line(headers), line(widths.map((width) => "-".repeat(width))), ...rows.map(line)].join("\n");
|
|
47
|
+
}
|
|
48
|
+
export const FOLLOW_UP_MARKER = "[FOLLOW_UPS:";
|
|
49
|
+
/**
|
|
50
|
+
* The /ask answer carries a trailing `[FOLLOW_UPS: "a" | "b"]` marker meant for
|
|
51
|
+
* the site chat UI. Split it off so stdout stays readable prose.
|
|
52
|
+
*
|
|
53
|
+
* The closing bracket is optional on purpose: the API truncates `answer` to a
|
|
54
|
+
* fixed length, so a long reply arrives with the marker cut off mid-word and no
|
|
55
|
+
* `]` at all (verified against production 2026-08-21). Anchoring on the bracket
|
|
56
|
+
* would leak the fragment into the output in exactly the common case.
|
|
57
|
+
*/
|
|
58
|
+
export function splitFollowUps(answer) {
|
|
59
|
+
const index = answer.indexOf(FOLLOW_UP_MARKER);
|
|
60
|
+
if (index === -1)
|
|
61
|
+
return { text: answer.trim(), followUps: [] };
|
|
62
|
+
const rest = answer.slice(index + FOLLOW_UP_MARKER.length);
|
|
63
|
+
const close = rest.indexOf("]");
|
|
64
|
+
const body = close === -1 ? rest : rest.slice(0, close);
|
|
65
|
+
const followUps = body
|
|
66
|
+
.split("|")
|
|
67
|
+
.map((entry) => entry.trim().replace(/^"|"$/g, "").trim())
|
|
68
|
+
// A truncated marker ends in a partial, unquoted entry. Keeping it would
|
|
69
|
+
// print half a sentence as if it were a real suggestion.
|
|
70
|
+
.filter((entry, position, all) => {
|
|
71
|
+
if (entry.length === 0)
|
|
72
|
+
return false;
|
|
73
|
+
const isLast = position === all.length - 1;
|
|
74
|
+
return !(isLast && close === -1);
|
|
75
|
+
});
|
|
76
|
+
return { text: answer.slice(0, index).trim(), followUps };
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Streaming counterpart: /api/chat emits the same marker, but a chunk boundary
|
|
80
|
+
* can fall inside it. This holds back any tail that could still turn out to be
|
|
81
|
+
* the start of the marker, so the filter never emits a partial `[FOLLOW`.
|
|
82
|
+
*/
|
|
83
|
+
export function createTrailerStripper() {
|
|
84
|
+
let held = "";
|
|
85
|
+
let finished = false;
|
|
86
|
+
return {
|
|
87
|
+
push(chunk) {
|
|
88
|
+
if (finished)
|
|
89
|
+
return "";
|
|
90
|
+
held += chunk;
|
|
91
|
+
const index = held.indexOf(FOLLOW_UP_MARKER);
|
|
92
|
+
if (index !== -1) {
|
|
93
|
+
// Nothing more will be emitted, so the blank line that separated the
|
|
94
|
+
// prose from the marker would just be trailing whitespace. Drop it, to
|
|
95
|
+
// match what splitFollowUps does on the non-streaming path.
|
|
96
|
+
const emitted = held.slice(0, index).replace(/\s+$/, "");
|
|
97
|
+
held = "";
|
|
98
|
+
finished = true;
|
|
99
|
+
return emitted;
|
|
100
|
+
}
|
|
101
|
+
// Longest suffix of the buffer that is also a prefix of the marker.
|
|
102
|
+
let keep = 0;
|
|
103
|
+
const max = Math.min(held.length, FOLLOW_UP_MARKER.length - 1);
|
|
104
|
+
for (let size = max; size > 0; size--) {
|
|
105
|
+
if (held.endsWith(FOLLOW_UP_MARKER.slice(0, size))) {
|
|
106
|
+
keep = size;
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
let holdFrom = held.length - keep;
|
|
111
|
+
// Hold the whitespace in front of a candidate marker too. If it does turn
|
|
112
|
+
// out to be the marker, that whitespace is the blank line separating the
|
|
113
|
+
// prose from it, and by then it is too late to unprint.
|
|
114
|
+
if (keep > 0) {
|
|
115
|
+
while (holdFrom > 0 && /\s/.test(held[holdFrom - 1]))
|
|
116
|
+
holdFrom--;
|
|
117
|
+
}
|
|
118
|
+
const emitted = held.slice(0, holdFrom);
|
|
119
|
+
held = held.slice(holdFrom);
|
|
120
|
+
return emitted;
|
|
121
|
+
},
|
|
122
|
+
flush() {
|
|
123
|
+
if (finished)
|
|
124
|
+
return "";
|
|
125
|
+
const remaining = held;
|
|
126
|
+
held = "";
|
|
127
|
+
finished = true;
|
|
128
|
+
return remaining;
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
export function indentList(items) {
|
|
133
|
+
return items.map((item) => ` - ${item}`).join("\n");
|
|
134
|
+
}
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { CliError, usageError } from "./errors.js";
|
|
2
|
+
export const DEFAULT_BASE_URL = "https://yonyon.ai";
|
|
3
|
+
/** LLM-backed endpoints think for a while; the flat JSON ones should not. */
|
|
4
|
+
export const SLOW_TIMEOUT_MS = 60_000;
|
|
5
|
+
export const FAST_TIMEOUT_MS = 15_000;
|
|
6
|
+
/** Normalize and validate a base URL so a typo fails here, not mid-request. */
|
|
7
|
+
export function resolveBaseUrl(raw) {
|
|
8
|
+
const candidate = raw ?? process.env["YONYON_BASE_URL"] ?? DEFAULT_BASE_URL;
|
|
9
|
+
let parsed;
|
|
10
|
+
try {
|
|
11
|
+
parsed = new URL(candidate);
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
throw usageError(`--base-url must be an absolute URL, got "${candidate}".`);
|
|
15
|
+
}
|
|
16
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
17
|
+
throw usageError(`--base-url must be http or https, got "${parsed.protocol}".`);
|
|
18
|
+
}
|
|
19
|
+
return parsed.origin + parsed.pathname.replace(/\/$/, "");
|
|
20
|
+
}
|
|
21
|
+
function describeRateLimit(response) {
|
|
22
|
+
const limit = response.headers.get("x-ratelimit-limit");
|
|
23
|
+
const retryAfter = response.headers.get("retry-after");
|
|
24
|
+
const ceiling = limit ? `${limit} requests per IP per minute` : "10 requests per IP per minute";
|
|
25
|
+
const wait = retryAfter ? ` Retry in ${retryAfter}s.` : "";
|
|
26
|
+
return new CliError(`Rate limited by yonyon.ai (${ceiling}).${wait}`, {
|
|
27
|
+
hints: [
|
|
28
|
+
"The public API is unauthenticated, so the budget is shared per source IP.",
|
|
29
|
+
"Wait for the window to reset and run the command again.",
|
|
30
|
+
],
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Reduce a non-JSON error body to one useful line.
|
|
35
|
+
*
|
|
36
|
+
* Returns "" when the body is markup with nothing quotable, because the status
|
|
37
|
+
* line alone reads better than a wall of HTML.
|
|
38
|
+
*/
|
|
39
|
+
export function summariseNonJsonBody(text) {
|
|
40
|
+
const trimmed = text.trim();
|
|
41
|
+
if (!trimmed)
|
|
42
|
+
return "";
|
|
43
|
+
const looksLikeHtml = /^\s*(<!doctype|<html|<\?xml)/i.test(trimmed);
|
|
44
|
+
if (looksLikeHtml) {
|
|
45
|
+
const title = /<title[^>]*>([^<]{1,200})<\/title>/i.exec(trimmed)?.[1]?.trim();
|
|
46
|
+
return title ? title.replace(/\s+/g, " ") : "";
|
|
47
|
+
}
|
|
48
|
+
// Plain text, so it is probably already the message. Collapse whitespace so a
|
|
49
|
+
// multi-line body does not smear across the terminal.
|
|
50
|
+
const oneLine = trimmed.replace(/\s+/g, " ");
|
|
51
|
+
return oneLine.length > 200 ? `${oneLine.slice(0, 200)}...` : oneLine;
|
|
52
|
+
}
|
|
53
|
+
/** Pull the most useful message out of an error body, whatever shape it has. */
|
|
54
|
+
async function describeFailure(response, url) {
|
|
55
|
+
if (response.status === 429)
|
|
56
|
+
return describeRateLimit(response);
|
|
57
|
+
let detail = "";
|
|
58
|
+
try {
|
|
59
|
+
const text = await response.text();
|
|
60
|
+
if (text) {
|
|
61
|
+
try {
|
|
62
|
+
const body = JSON.parse(text);
|
|
63
|
+
if (body !== null && typeof body === "object") {
|
|
64
|
+
const record = body;
|
|
65
|
+
const message = record["error"] ?? record["message"];
|
|
66
|
+
const code = record["code"];
|
|
67
|
+
if (typeof message === "string")
|
|
68
|
+
detail = message;
|
|
69
|
+
if (typeof code === "string")
|
|
70
|
+
detail = detail ? `${detail} (${code})` : code;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// Not JSON. An HTML error page is the common case here (a wrong
|
|
75
|
+
// --base-url lands on the site's 404 page, not the API), and echoing
|
|
76
|
+
// 300 characters of `<!DOCTYPE html><html lang="en"...` into the
|
|
77
|
+
// terminal buries the one line that matters. Prefer the <title>, and
|
|
78
|
+
// otherwise say nothing rather than paste markup.
|
|
79
|
+
detail = summariseNonJsonBody(text);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// A body we cannot read is not more informative than the status line.
|
|
85
|
+
}
|
|
86
|
+
const suffix = detail ? `: ${detail}` : "";
|
|
87
|
+
return new CliError(`Request to ${url} failed with HTTP ${response.status}${suffix}`);
|
|
88
|
+
}
|
|
89
|
+
/** Turn fetch's opaque failures into something a human can act on. */
|
|
90
|
+
function describeNetworkFailure(error, url, timeoutMs) {
|
|
91
|
+
if (error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError")) {
|
|
92
|
+
return new CliError(`Request to ${url} timed out after ${timeoutMs}ms.`, {
|
|
93
|
+
hints: ["Raise the ceiling with --timeout <ms>, or retry."],
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
97
|
+
return new CliError(`Could not reach ${url}: ${reason}`, {
|
|
98
|
+
hints: ["Check your network connection, or point elsewhere with --base-url."],
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
async function send(url, init, timeoutMs) {
|
|
102
|
+
const controller = new AbortController();
|
|
103
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
104
|
+
try {
|
|
105
|
+
return await fetch(url, { ...init, signal: controller.signal });
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
throw describeNetworkFailure(error, url, timeoutMs);
|
|
109
|
+
}
|
|
110
|
+
finally {
|
|
111
|
+
clearTimeout(timer);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
export async function getJson(ctx, path) {
|
|
115
|
+
const url = `${ctx.baseUrl}${path}`;
|
|
116
|
+
const response = await send(url, { headers: { accept: "application/json" } }, ctx.timeoutMs);
|
|
117
|
+
if (!response.ok)
|
|
118
|
+
throw await describeFailure(response, url);
|
|
119
|
+
return (await response.json());
|
|
120
|
+
}
|
|
121
|
+
export async function postJson(ctx, path, body, extraHeaders = {}) {
|
|
122
|
+
const url = `${ctx.baseUrl}${path}`;
|
|
123
|
+
const response = await send(url, {
|
|
124
|
+
method: "POST",
|
|
125
|
+
headers: { "content-type": "application/json", accept: "application/json", ...extraHeaders },
|
|
126
|
+
body: JSON.stringify(body),
|
|
127
|
+
}, ctx.timeoutMs);
|
|
128
|
+
if (!response.ok)
|
|
129
|
+
throw await describeFailure(response, url);
|
|
130
|
+
return (await response.json());
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* POST and stream the text body back chunk by chunk.
|
|
134
|
+
*
|
|
135
|
+
* `/api/chat` enforces a CSRF check that rejects any request without an
|
|
136
|
+
* `Origin` header whose host matches the request host (verified 2026-08-21: a
|
|
137
|
+
* bare POST returns HTTP 403 `{"error":"Forbidden","code":"forbidden"}`). We
|
|
138
|
+
* send the base URL as the Origin so the CLI is a first-class caller rather
|
|
139
|
+
* than a blocked one. Callers that only need one answer should use /ask.
|
|
140
|
+
*/
|
|
141
|
+
export async function postStream(ctx, path, body, onChunk) {
|
|
142
|
+
const url = `${ctx.baseUrl}${path}`;
|
|
143
|
+
const response = await send(url, {
|
|
144
|
+
method: "POST",
|
|
145
|
+
headers: {
|
|
146
|
+
"content-type": "application/json",
|
|
147
|
+
accept: "text/plain",
|
|
148
|
+
origin: ctx.baseUrl,
|
|
149
|
+
},
|
|
150
|
+
body: JSON.stringify(body),
|
|
151
|
+
}, ctx.timeoutMs);
|
|
152
|
+
if (!response.ok)
|
|
153
|
+
throw await describeFailure(response, url);
|
|
154
|
+
if (!response.body) {
|
|
155
|
+
onChunk(await response.text());
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const decoder = new TextDecoder();
|
|
159
|
+
const reader = response.body.getReader();
|
|
160
|
+
try {
|
|
161
|
+
for (;;) {
|
|
162
|
+
const { done, value } = await reader.read();
|
|
163
|
+
if (done)
|
|
164
|
+
break;
|
|
165
|
+
if (value)
|
|
166
|
+
onChunk(decoder.decode(value, { stream: true }));
|
|
167
|
+
}
|
|
168
|
+
const tail = decoder.decode();
|
|
169
|
+
if (tail)
|
|
170
|
+
onChunk(tail);
|
|
171
|
+
}
|
|
172
|
+
catch (error) {
|
|
173
|
+
throw describeNetworkFailure(error, url, ctx.timeoutMs);
|
|
174
|
+
}
|
|
175
|
+
}
|
package/dist/index.js
ADDED
package/dist/io.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export const processIo = {
|
|
2
|
+
out: (text) => process.stdout.write(text),
|
|
3
|
+
err: (text) => process.stderr.write(text),
|
|
4
|
+
isTty: Boolean(process.stdout.isTTY),
|
|
5
|
+
};
|
|
6
|
+
/** Convenience: write a line to stdout. */
|
|
7
|
+
export function line(io, text = "") {
|
|
8
|
+
io.out(`${text}\n`);
|
|
9
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "yonyon",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Command line client for the public yonyon.ai agent API. Ask questions, list projects, wire up the MCP server, book an intro call. No auth, no config.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"yonyon": "./dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"main": "./dist/index.js",
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=24"
|
|
17
|
+
},
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"author": "Yonatan Gross <yonaigross@gmail.com> (https://yonyon.ai)",
|
|
20
|
+
"homepage": "https://yonyon.ai",
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/Yonatan-HQ/portfolio.git",
|
|
24
|
+
"directory": "cli"
|
|
25
|
+
},
|
|
26
|
+
"bugs": {
|
|
27
|
+
"url": "https://github.com/Yonatan-HQ/portfolio/issues"
|
|
28
|
+
},
|
|
29
|
+
"keywords": [
|
|
30
|
+
"mcp",
|
|
31
|
+
"ai",
|
|
32
|
+
"agent",
|
|
33
|
+
"agents",
|
|
34
|
+
"llm",
|
|
35
|
+
"cli",
|
|
36
|
+
"model-context-protocol",
|
|
37
|
+
"yonyon",
|
|
38
|
+
"agent-readiness",
|
|
39
|
+
"nlweb"
|
|
40
|
+
],
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public"
|
|
43
|
+
},
|
|
44
|
+
"scripts": {
|
|
45
|
+
"build": "tsc -p tsconfig.build.json",
|
|
46
|
+
"typecheck": "tsc --noEmit",
|
|
47
|
+
"test": "vitest run",
|
|
48
|
+
"test:watch": "vitest",
|
|
49
|
+
"prepublishOnly": "npm run build"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@types/node": "^24.10.1",
|
|
53
|
+
"typescript": "^6.0.3",
|
|
54
|
+
"vitest": "^4.1.10"
|
|
55
|
+
}
|
|
56
|
+
}
|