@kairon/cli 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/README.md +212 -0
- package/dist/args.js +80 -0
- package/dist/browser.js +33 -0
- package/dist/catalogue.js +99 -0
- package/dist/client.js +45 -0
- package/dist/commands/call.js +96 -0
- package/dist/commands/login.js +77 -0
- package/dist/commands/logout.js +21 -0
- package/dist/commands/whoami.js +51 -0
- package/dist/config.js +87 -0
- package/dist/errors.js +23 -0
- package/dist/flags.js +254 -0
- package/dist/help.js +126 -0
- package/dist/index.js +155 -0
- package/dist/naming.js +55 -0
- package/dist/oauth/discovery.js +62 -0
- package/dist/oauth/loopback.js +99 -0
- package/dist/oauth/pkce.js +17 -0
- package/dist/oauth/register.js +37 -0
- package/dist/oauth/tokens.js +100 -0
- package/dist/server.js +40 -0
- package/dist/session.js +54 -0
- package/dist/version.js +3 -0
- package/package.json +53 -0
package/README.md
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# `@kairon/cli` — Kairon from a terminal
|
|
2
|
+
|
|
3
|
+
The second programmatic door (PRD-57, ADR-0054 §7). It is an **MCP client**, not a
|
|
4
|
+
second API: every command goes through `POST /mcp` on a real Kairon, so a CLI call
|
|
5
|
+
and an agent call are the same call — the same `channel_action` ledger row with
|
|
6
|
+
`source: 'mcp'`, the same daily caps, the same [[Limit Override]]s. MCP is the door
|
|
7
|
+
for a *model*; this is the door for a *script*.
|
|
8
|
+
|
|
9
|
+
Nothing here has its own surface to keep in sync. Commands are generated from the
|
|
10
|
+
server's `tools/list`, so the CLI cannot drift from the tools.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npm i -g @kairon/cli # then: kairon login
|
|
16
|
+
npx @kairon/cli whoami # or run it once, installing nothing
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Needs Node ≥ 22.23.1. Working on the CLI itself:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pnpm --filter @kairon/cli build # dist/index.js, the `kairon` binary
|
|
23
|
+
pnpm --filter @kairon/cli kairon -- whoami # run from source with tsx
|
|
24
|
+
pnpm --filter @kairon/cli test
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
### Releasing
|
|
28
|
+
|
|
29
|
+
`.github/workflows/release-cli.yml` publishes on a `cli-v*` tag, and refuses if the tag
|
|
30
|
+
disagrees with `package.json`. The token lives in the `NPM_TOKEN` repository secret and
|
|
31
|
+
nowhere else — never in a committed `.npmrc`, which would put it in every clone and
|
|
32
|
+
every fork.
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pnpm --filter @kairon/cli exec npm version minor # bump, and mirror it in src/version.ts
|
|
36
|
+
git tag cli-v0.2.0 && git push origin cli-v0.2.0
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`version.ts` is checked against `package.json` by a spec, because `kairon --version` and
|
|
40
|
+
the MCP client handshake both report the constant: if they drift, every bug report cites
|
|
41
|
+
a version that does not exist. Run the workflow manually (`workflow_dispatch`) to
|
|
42
|
+
rehearse a release — it builds, tests and packs without publishing.
|
|
43
|
+
|
|
44
|
+
## Commands
|
|
45
|
+
|
|
46
|
+
There is **no command list in this package.** A tool's name is its command: split on
|
|
47
|
+
the first `_`, kebab-case the rest.
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
list_add → kairon list add
|
|
51
|
+
campaign_run_get → kairon campaign run-get
|
|
52
|
+
search_sales_navigator → kairon search sales-navigator
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
That absence is the design, and `no-command-table.spec.ts` is there to keep it —
|
|
56
|
+
it greps these sources for tool-name-shaped literals and fails on any of them. A
|
|
57
|
+
table would be a second definition of the surface, and a second definition drifts:
|
|
58
|
+
add a tool server-side, forget the entry, and a command silently doesn't exist.
|
|
59
|
+
|
|
60
|
+
Flags come from the tool's JSON Schema. Scalars are `--flag`; nested objects are dot
|
|
61
|
+
paths; arrays and unions take JSON, because there is no honest flat spelling for
|
|
62
|
+
"an array of either a memberId or a url". `--json` passes the whole input.
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
kairon chat list --unread # scalar
|
|
66
|
+
kairon signal search --signal posted_with_keyword \
|
|
67
|
+
--config.keyword "platform migration" # nested
|
|
68
|
+
kairon list add --asset leads --id list_… \
|
|
69
|
+
--items '[{"url":"https://linkedin.com/in/ada"}]' # array as JSON
|
|
70
|
+
kairon list create --json '{"asset":"leads","name":"Q3"}' # the escape hatch
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Input is checked against the cached schema **before** any network call, so a typo
|
|
74
|
+
costs nothing and names itself:
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
$ kairon list list --aset leads
|
|
78
|
+
Unknown option "--aset" for "kairon list list".
|
|
79
|
+
|
|
80
|
+
Did you mean --asset?
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
`tools/list` is cached per server URL under `~/.kairon/tools/`, so `--help` works
|
|
84
|
+
offline — and before you have ever logged in. `--refresh` re-fetches; there is no
|
|
85
|
+
TTL, because a tool set changes on deploy, not on a schedule.
|
|
86
|
+
|
|
87
|
+
## Output
|
|
88
|
+
|
|
89
|
+
The result is JSON on **stdout**; everything a human reads is on **stderr**. So this
|
|
90
|
+
works with no flags:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
kairon campaign stats --id camp_… | jq '.funnel'
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
A failed tool exits non-zero, writes **nothing** to stdout, and leads with the
|
|
97
|
+
`AppException` code — the stable thing to branch on (ADR-0004):
|
|
98
|
+
|
|
99
|
+
```
|
|
100
|
+
$ kairon list get --asset leads --id list_01… ; echo "exit=$?"
|
|
101
|
+
kairon list get failed: COMMON_NOT_FOUND
|
|
102
|
+
{
|
|
103
|
+
"code": "COMMON_NOT_FOUND",
|
|
104
|
+
"params": { "resource": "lead_list" },
|
|
105
|
+
"detail": "No lead list list_01… in this org"
|
|
106
|
+
}
|
|
107
|
+
exit=1
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
One rough edge, verified live and **not** the CLI's to fix: a *malformed* id (bad
|
|
111
|
+
prefix, wrong shape) escapes the API as a raw `TypeID` error rather than an
|
|
112
|
+
`AppException`, so it arrives as `unknown_error` with prose instead of a code. It is
|
|
113
|
+
legible, but it is not the contract. Tracked as KAI-430 — it affects every MCP
|
|
114
|
+
caller, not just this one.
|
|
115
|
+
|
|
116
|
+
## Auth
|
|
117
|
+
|
|
118
|
+
`kairon login` runs OAuth 2.1 authorization code + PKCE with Dynamic Client
|
|
119
|
+
Registration against the same authorization server the Claude connector uses. There
|
|
120
|
+
is no API key, and that is a decision, not a gap (ADR-0017 §2).
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
kairon login # production
|
|
124
|
+
kairon login --server http://localhost:3055 # a local API
|
|
125
|
+
kairon whoami
|
|
126
|
+
kairon logout
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Credentials live in `~/.kairon/config.json` at mode `0600`, keyed by server, so a
|
|
130
|
+
dev login never disturbs the production one. Access tokens (30d) refresh silently;
|
|
131
|
+
when the refresh token (90d) finally dies you get one sentence and the command to
|
|
132
|
+
run, not a stack trace.
|
|
133
|
+
|
|
134
|
+
**Headless** — CI, cron, a container:
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
KAIRON_TOKEN=<access token> kairon whoami # no ~/.kairon needed at all
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
| Variable | What it does |
|
|
141
|
+
|---|---|
|
|
142
|
+
| `KAIRON_API_URL` | Default server; `--server` beats it, production is the fallback |
|
|
143
|
+
| `KAIRON_TOKEN` | Use this access token and ignore stored credentials entirely |
|
|
144
|
+
| `KAIRON_CONFIG_DIR` | Where credentials live (default `~/.kairon`) |
|
|
145
|
+
|
|
146
|
+
Two things the server does **not** offer, which shape the client:
|
|
147
|
+
|
|
148
|
+
- **No JWKS.** Access tokens are opaque and introspected server-side, so the CLI
|
|
149
|
+
cannot validate one locally — it finds out on the call.
|
|
150
|
+
- **No revocation endpoint.** `kairon logout` deletes the local credentials; the
|
|
151
|
+
access token stays valid on the server until it expires. The command says so
|
|
152
|
+
rather than implying a revocation that didn't happen.
|
|
153
|
+
|
|
154
|
+
## Signing in against a local Kairon
|
|
155
|
+
|
|
156
|
+
**Point `BETTER_AUTH_URL` at the web origin, not the bare API port.** Otherwise no
|
|
157
|
+
MCP client can authenticate locally — this CLI or the Claude connector — and the
|
|
158
|
+
symptom is silent: sign-in appears to succeed and the authorize endpoint bounces
|
|
159
|
+
back to the login page forever.
|
|
160
|
+
|
|
161
|
+
The cause is an origin split that only exists in dev. The web app reaches the API
|
|
162
|
+
through Vite's proxy on its *own* origin (`auth-client.ts` sets no `baseURL`), so
|
|
163
|
+
Better Auth's session cookie is host-only on `https://<worktree>.kairon.localhost`.
|
|
164
|
+
A `BETTER_AUTH_URL` of `http://localhost:<port>` then advertises
|
|
165
|
+
`authorization_endpoint` on a **different origin**, which that cookie never reaches.
|
|
166
|
+
Production has no such split — `app.heykairon.com` serves the SPA and the API
|
|
167
|
+
together — so the one-origin shape is also the truthful local rehearsal.
|
|
168
|
+
|
|
169
|
+
`vite.config.ts` proxies `/mcp` and `/.well-known/*` for exactly this reason. With
|
|
170
|
+
that in place:
|
|
171
|
+
|
|
172
|
+
```bash
|
|
173
|
+
BETTER_AUTH_URL=https://<worktree>.kairon.localhost pnpm dev
|
|
174
|
+
NODE_EXTRA_CA_CERTS=~/.portless/ca.pem \
|
|
175
|
+
kairon login --server https://<worktree>.kairon.localhost
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
`NODE_EXTRA_CA_CERTS` is needed because portless serves its own certificate, which
|
|
179
|
+
Node does not trust by default. Two gotchas worth knowing:
|
|
180
|
+
|
|
181
|
+
- Changing `BETTER_AUTH_URL`'s **scheme** invalidates any existing browser session.
|
|
182
|
+
Better Auth derives the cookie *name* from it (`better-auth.session_token` over
|
|
183
|
+
http, `__Secure-better-auth.session_token` over https), so the old cookie is not
|
|
184
|
+
so much rejected as never looked for. Sign in again.
|
|
185
|
+
- The whole flow was verified end to end this way on 2026-07-25 against real Better
|
|
186
|
+
Auth: DCR, PKCE authorize, code exchange (a refresh token *is* issued for
|
|
187
|
+
`offline_access`, with the 30-day access-token TTL from ADR-0017), silent refresh,
|
|
188
|
+
`whoami`, and `logout`.
|
|
189
|
+
|
|
190
|
+
## Tests
|
|
191
|
+
|
|
192
|
+
`src/testing/fake-kairon.ts` is a real HTTP server speaking the same wire protocol
|
|
193
|
+
the API does — discovery, DCR, `authorize`, `token`, `GET /mcp/me`, and an actual
|
|
194
|
+
stateless MCP endpoint. The specs drive the whole browser round trip over sockets,
|
|
195
|
+
including a fake "browser" that follows the 302 into the CLI's loopback listener.
|
|
196
|
+
|
|
197
|
+
That is deliberate. A mocked `fetch` would pass while being wrong in every way that
|
|
198
|
+
actually bites: a redirect that never fires, a `code_verifier` that hashes to
|
|
199
|
+
something else, a loopback port the browser can't reach, an MCP handshake the SDK
|
|
200
|
+
rejects. The fake is strict exactly where the client could be wrong — PKCE
|
|
201
|
+
verification, exact redirect-URI matching, `state`, bearer checks. Its tool schemas
|
|
202
|
+
are copied from a real `tools/list`, so flag derivation meets the shapes it will
|
|
203
|
+
actually meet: a nested object, an enum, an array of a union.
|
|
204
|
+
|
|
205
|
+
The command surface was also driven against a **real local Kairon** (2026-07-26):
|
|
206
|
+
32 tools discovered and cached, `list create` → `list list` → `list delete` round
|
|
207
|
+
trip, `| jq` with no flags, `COMMON_NOT_FOUND` surfaced with exit 1 and an empty
|
|
208
|
+
stdout, and a typo refused before any connection. Rendering the *production*
|
|
209
|
+
catalogue's help is what caught the one real bug in this slice: `--help` listed
|
|
210
|
+
`--annualRevenue.currency` and two siblings as **required**, because a leaf inside
|
|
211
|
+
an optional object was being read as required on its own. Nested requiredness now
|
|
212
|
+
depends on every ancestor.
|
package/dist/args.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { CliError } from './errors.js';
|
|
2
|
+
/**
|
|
3
|
+
* A ~40-line argv parser instead of a dependency.
|
|
4
|
+
*
|
|
5
|
+
* Slice 2 (KAI-415) generates commands from the server's `tools/list`, so the
|
|
6
|
+
* flags this has to accept are not known at build time — they're whatever the
|
|
7
|
+
* MCP input schemas say. A parser that hands back a plain `flags` bag is a
|
|
8
|
+
* better fit for that than a library whose model is "declare your options up
|
|
9
|
+
* front", and it keeps the CLI's dependency list at exactly one (the MCP SDK).
|
|
10
|
+
*
|
|
11
|
+
* Accepts `--flag value`, `--flag=value`, `--bool`, and `--no-bool`. A bare `--`
|
|
12
|
+
* ends flag parsing, so a value that looks like a flag can still be passed.
|
|
13
|
+
*/
|
|
14
|
+
export function parseArgs(argv) {
|
|
15
|
+
const positionals = [];
|
|
16
|
+
const flags = {};
|
|
17
|
+
let command;
|
|
18
|
+
let literal = false;
|
|
19
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
20
|
+
const arg = argv[i];
|
|
21
|
+
if (arg === undefined)
|
|
22
|
+
continue;
|
|
23
|
+
if (literal || !arg.startsWith('-')) {
|
|
24
|
+
if (command === undefined && !literal)
|
|
25
|
+
command = arg;
|
|
26
|
+
else
|
|
27
|
+
positionals.push(arg);
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (arg === '--') {
|
|
31
|
+
literal = true;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
// `-h` / `-v` are the only short flags; everything else is long-form, because
|
|
35
|
+
// generated tool flags come from schema keys and those are words, not letters.
|
|
36
|
+
if (!arg.startsWith('--')) {
|
|
37
|
+
const short = arg.slice(1);
|
|
38
|
+
if (short === 'h')
|
|
39
|
+
flags.help = true;
|
|
40
|
+
else if (short === 'v')
|
|
41
|
+
flags.version = true;
|
|
42
|
+
else
|
|
43
|
+
throw new CliError(`Unknown option "${arg}".`, 'kairon --help');
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
const body = arg.slice(2);
|
|
47
|
+
if (body.length === 0)
|
|
48
|
+
throw new CliError('Empty option "--".', 'kairon --help');
|
|
49
|
+
const eq = body.indexOf('=');
|
|
50
|
+
if (eq !== -1) {
|
|
51
|
+
flags[body.slice(0, eq)] = body.slice(eq + 1);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (body.startsWith('no-')) {
|
|
55
|
+
flags[body.slice(3)] = false;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
// A following token is this flag's value unless it is itself a flag, which
|
|
59
|
+
// makes `--server` with nothing after it an error rather than a silent `true`.
|
|
60
|
+
const next = argv[i + 1];
|
|
61
|
+
if (next !== undefined && !next.startsWith('-')) {
|
|
62
|
+
flags[body] = next;
|
|
63
|
+
i += 1;
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
flags[body] = true;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return { command, positionals, flags };
|
|
70
|
+
}
|
|
71
|
+
/** Read a flag that must carry a string value, so `--server` alone fails loudly. */
|
|
72
|
+
export function stringFlag(flags, name) {
|
|
73
|
+
const value = flags[name];
|
|
74
|
+
if (value === undefined)
|
|
75
|
+
return undefined;
|
|
76
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
77
|
+
throw new CliError(`--${name} needs a value.`, `kairon --${name} <value>`);
|
|
78
|
+
}
|
|
79
|
+
return value;
|
|
80
|
+
}
|
package/dist/browser.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
/**
|
|
3
|
+
* Open a URL in the user's browser, best-effort.
|
|
4
|
+
*
|
|
5
|
+
* Best-effort is the whole design: `login` prints the URL before calling this and
|
|
6
|
+
* keeps waiting on the loopback listener either way, so a headless box, an SSH
|
|
7
|
+
* session, or a locked-down desktop degrades to "paste this link" instead of
|
|
8
|
+
* hanging on a browser that was never going to appear. Nothing here is awaited or
|
|
9
|
+
* checked for that reason.
|
|
10
|
+
*/
|
|
11
|
+
export function openBrowser(url) {
|
|
12
|
+
const [command, args] = process.platform === 'darwin'
|
|
13
|
+
? ['open', [url]]
|
|
14
|
+
: process.platform === 'win32'
|
|
15
|
+
? // `start` is a cmd builtin, and its first quoted argument is the window
|
|
16
|
+
// title — hence the empty string, or a URL with spaces becomes the title
|
|
17
|
+
// and nothing opens.
|
|
18
|
+
['cmd', ['/c', 'start', '', url]]
|
|
19
|
+
: ['xdg-open', [url]];
|
|
20
|
+
try {
|
|
21
|
+
const child = spawn(command, args, {
|
|
22
|
+
stdio: 'ignore',
|
|
23
|
+
detached: true,
|
|
24
|
+
});
|
|
25
|
+
child.on('error', () => {
|
|
26
|
+
/* No browser on this machine. The printed URL is the fallback. */
|
|
27
|
+
});
|
|
28
|
+
child.unref();
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
/* Same. */
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { connectMcp } from './client.js';
|
|
4
|
+
import { configDir } from './config.js';
|
|
5
|
+
import { resolveAccessToken } from './session.js';
|
|
6
|
+
/**
|
|
7
|
+
* Where one server's catalogue is cached.
|
|
8
|
+
*
|
|
9
|
+
* Keyed by server URL, so a dev Kairon and production never answer for each other —
|
|
10
|
+
* they legitimately have different tool sets mid-refactor, and a shared cache would
|
|
11
|
+
* make `--help` lie about whichever you used last. The URL is percent-encoded into a
|
|
12
|
+
* single filename rather than split into directories: it keeps `~/.kairon/tools/`
|
|
13
|
+
* flat and greppable, and the encoding is reversible if anyone needs to know what a
|
|
14
|
+
* file is for.
|
|
15
|
+
*/
|
|
16
|
+
export function cataloguePath(server, env = process.env) {
|
|
17
|
+
return join(configDir(env), 'tools', `${encodeURIComponent(server)}.json`);
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* The tool catalogue for a server (MR57).
|
|
21
|
+
*
|
|
22
|
+
* Cache-first, because `--help` is the command a user runs most and it should not
|
|
23
|
+
* need a network — or a login. `--refresh` re-fetches, and so does a cache miss.
|
|
24
|
+
*
|
|
25
|
+
* The cache never expires on a timer. A tool set changes on deploy, not on a
|
|
26
|
+
* schedule, so a TTL would only ever be wrong in one of two directions: too short
|
|
27
|
+
* and every help is a round trip, too long and `--refresh` is the real fix anyway.
|
|
28
|
+
* Making staleness the user's explicit call is both simpler and more honest than a
|
|
29
|
+
* number pretending to know how often we ship.
|
|
30
|
+
*/
|
|
31
|
+
export async function loadCatalogue(server, opts = {}) {
|
|
32
|
+
const env = opts.env ?? process.env;
|
|
33
|
+
if (!opts.refresh) {
|
|
34
|
+
const cached = await readCatalogue(server, env);
|
|
35
|
+
if (cached)
|
|
36
|
+
return cached;
|
|
37
|
+
}
|
|
38
|
+
const tools = await fetchCatalogue(server, env);
|
|
39
|
+
await writeCatalogue(server, tools, env);
|
|
40
|
+
return tools;
|
|
41
|
+
}
|
|
42
|
+
/** The cached catalogue, or undefined when absent, unreadable, or for another server. */
|
|
43
|
+
export async function readCatalogue(server, env = process.env) {
|
|
44
|
+
let raw;
|
|
45
|
+
try {
|
|
46
|
+
raw = await readFile(cataloguePath(server, env), 'utf8');
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
const parsed = JSON.parse(raw);
|
|
53
|
+
// A cache holds nothing that cannot be re-fetched, so anything unexpected —
|
|
54
|
+
// a truncated file, a future version, a different server — degrades to a miss
|
|
55
|
+
// rather than an error the user has to clear by hand.
|
|
56
|
+
if (parsed.version !== 1 || parsed.server !== server || !Array.isArray(parsed.tools)) {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
return parsed.tools;
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
async function fetchCatalogue(server, env) {
|
|
66
|
+
const token = await resolveAccessToken(server, env);
|
|
67
|
+
const client = await connectMcp(server, token);
|
|
68
|
+
try {
|
|
69
|
+
const { tools } = await client.listTools();
|
|
70
|
+
return tools.map((tool) => ({
|
|
71
|
+
name: tool.name,
|
|
72
|
+
...(typeof tool.title === 'string' ? { title: tool.title } : {}),
|
|
73
|
+
...(typeof tool.description === 'string' ? { description: tool.description } : {}),
|
|
74
|
+
...(tool.inputSchema ? { inputSchema: tool.inputSchema } : {}),
|
|
75
|
+
}));
|
|
76
|
+
}
|
|
77
|
+
finally {
|
|
78
|
+
await client.close();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Persist the catalogue. Written to a temp file and renamed, like the credential
|
|
83
|
+
* file: two `kairon` processes racing must leave a whole cache behind, not a
|
|
84
|
+
* half-written one that every later `--help` then treats as a miss.
|
|
85
|
+
*/
|
|
86
|
+
async function writeCatalogue(server, tools, env) {
|
|
87
|
+
const path = cataloguePath(server, env);
|
|
88
|
+
const file = { version: 1, server, fetchedAt: new Date().toISOString(), tools };
|
|
89
|
+
try {
|
|
90
|
+
await mkdir(join(configDir(env), 'tools'), { recursive: true, mode: 0o700 });
|
|
91
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
92
|
+
await writeFile(tmp, `${JSON.stringify(file, null, 2)}\n`, { mode: 0o600 });
|
|
93
|
+
await rename(tmp, path);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
// A cache that cannot be written (read-only home, full disk) must not fail the
|
|
97
|
+
// command the user actually asked for — they lose offline help, nothing else.
|
|
98
|
+
}
|
|
99
|
+
}
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
2
|
+
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
|
3
|
+
import { CliError } from './errors.js';
|
|
4
|
+
import { CLI_NAME, CLI_VERSION } from './version.js';
|
|
5
|
+
/**
|
|
6
|
+
* Connect to a Kairon MCP server as a real MCP client (ADR-0017).
|
|
7
|
+
*
|
|
8
|
+
* The bearer rides as a plain header rather than through the SDK's `authProvider`
|
|
9
|
+
* hook: this CLI already owns the whole OAuth lifecycle in `oauth/` and refreshes
|
|
10
|
+
* *before* the call in `session.ts`, so handing the SDK a second, partial notion
|
|
11
|
+
* of auth would give the same token two owners.
|
|
12
|
+
*
|
|
13
|
+
* The door is stateless, so there is no session to keep alive between commands —
|
|
14
|
+
* each command connects, asks, and closes.
|
|
15
|
+
*/
|
|
16
|
+
export async function connectMcp(server, accessToken) {
|
|
17
|
+
const transport = new StreamableHTTPClientTransport(new URL(`${server}/mcp`), {
|
|
18
|
+
requestInit: { headers: { Authorization: `Bearer ${accessToken}` } },
|
|
19
|
+
});
|
|
20
|
+
const client = new Client({ name: CLI_NAME, version: CLI_VERSION });
|
|
21
|
+
try {
|
|
22
|
+
await client.connect(transport);
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
throw asReadableConnectError(error, server);
|
|
26
|
+
}
|
|
27
|
+
return client;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Turn a transport-level failure into something worth reading.
|
|
31
|
+
*
|
|
32
|
+
* The SDK reports HTTP failures as `Error: ... HTTP 403 ...`, which tells a user
|
|
33
|
+
* nothing about the two states they can actually resolve: a token that has gone
|
|
34
|
+
* stale, and an account with no LinkedIn seat connected yet.
|
|
35
|
+
*/
|
|
36
|
+
export function asReadableConnectError(error, server) {
|
|
37
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
38
|
+
if (message.includes('401')) {
|
|
39
|
+
return new CliError(`${server} rejected your credentials.`, `kairon login --server ${server}`);
|
|
40
|
+
}
|
|
41
|
+
if (message.includes('403')) {
|
|
42
|
+
return new CliError(`${server} accepted your login but refused the request. Either no LinkedIn account is connected, or the subscription is inactive.`, 'Connect an account in the Kairon web app, then try again.');
|
|
43
|
+
}
|
|
44
|
+
return new CliError(`Could not reach the Kairon MCP server at ${server}/mcp: ${message}`);
|
|
45
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { connectMcp } from '../client.js';
|
|
2
|
+
import { CliError } from '../errors.js';
|
|
3
|
+
import { buildInput, validateInput } from '../flags.js';
|
|
4
|
+
import { displayName } from '../naming.js';
|
|
5
|
+
import { resolveAccessToken } from '../session.js';
|
|
6
|
+
/**
|
|
7
|
+
* A tool error the server returned — the CLI's own failure mode for MR56.
|
|
8
|
+
*
|
|
9
|
+
* Separate from {@link CliError} because it is not the CLI's fault and carries a
|
|
10
|
+
* machine `code` the caller may script against (ADR-0004). `index.ts` prints the
|
|
11
|
+
* code first and exits non-zero.
|
|
12
|
+
*/
|
|
13
|
+
export class ToolFailed extends Error {
|
|
14
|
+
command;
|
|
15
|
+
code;
|
|
16
|
+
payload;
|
|
17
|
+
constructor(command,
|
|
18
|
+
/** The `AppException` code, e.g. `COMMON_NOT_FOUND`. `unknown_error` if absent. */
|
|
19
|
+
code,
|
|
20
|
+
/** The whole error payload, printed as JSON so a script can read it. */
|
|
21
|
+
payload, detail) {
|
|
22
|
+
super(detail ?? code);
|
|
23
|
+
this.command = command;
|
|
24
|
+
this.code = code;
|
|
25
|
+
this.payload = payload;
|
|
26
|
+
this.name = 'ToolFailed';
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Run one generated command (MR54, MR55, MR56).
|
|
31
|
+
*
|
|
32
|
+
* The stream split is the contract: **the result is the only thing on stdout**, so
|
|
33
|
+
* `kairon list list --asset leads | jq '.items'` works with no flags and no
|
|
34
|
+
* `--quiet`. Anything a human wants to read — progress, warnings, errors — goes to
|
|
35
|
+
* stderr, where a pipe never sees it. This is why nothing here writes a friendly
|
|
36
|
+
* line to `out`, however tempting: one stray `Fetching…` and every downstream `jq`
|
|
37
|
+
* breaks.
|
|
38
|
+
*
|
|
39
|
+
* Validation happens before `connectMcp`, so a typo'd flag costs nothing and names
|
|
40
|
+
* itself rather than becoming a 422 after a round trip and an OAuth refresh.
|
|
41
|
+
*/
|
|
42
|
+
export async function callTool(server, tool, flags, out = process.stdout, env = process.env) {
|
|
43
|
+
const command = displayName(tool.name);
|
|
44
|
+
const input = buildInput(flags, tool.inputSchema, command);
|
|
45
|
+
validateInput(input, tool.inputSchema, command);
|
|
46
|
+
const token = await resolveAccessToken(server, env);
|
|
47
|
+
const client = await connectMcp(server, token);
|
|
48
|
+
let result;
|
|
49
|
+
try {
|
|
50
|
+
result = await client.callTool({ name: tool.name, arguments: input });
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
await client.close();
|
|
54
|
+
}
|
|
55
|
+
const payload = readPayload(result);
|
|
56
|
+
if (result.isError) {
|
|
57
|
+
const error = (payload ?? {});
|
|
58
|
+
throw new ToolFailed(command, typeof error.code === 'string' ? error.code : 'unknown_error', payload, typeof error.detail === 'string' ? error.detail : undefined);
|
|
59
|
+
}
|
|
60
|
+
out.write(`${JSON.stringify(payload ?? null, null, 2)}\n`);
|
|
61
|
+
return payload;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The tool's own JSON out of the MCP envelope.
|
|
65
|
+
*
|
|
66
|
+
* `structuredContent` is preferred when the server sends it (it is already the
|
|
67
|
+
* object), and the text block is parsed otherwise — our door serialises the result
|
|
68
|
+
* with `JSON.stringify` (tool-catalogue.ts), so it round-trips. A tool that answers
|
|
69
|
+
* with genuinely non-JSON text still prints, as text, rather than becoming an error:
|
|
70
|
+
* printing what the server said beats inventing a failure it did not report.
|
|
71
|
+
*/
|
|
72
|
+
export function readPayload(result) {
|
|
73
|
+
const envelope = (result ?? {});
|
|
74
|
+
if (envelope.structuredContent !== undefined)
|
|
75
|
+
return envelope.structuredContent;
|
|
76
|
+
const blocks = Array.isArray(envelope.content) ? envelope.content : [];
|
|
77
|
+
const text = blocks
|
|
78
|
+
.filter((block) => {
|
|
79
|
+
const candidate = block;
|
|
80
|
+
return candidate.type === 'text' && typeof candidate.text === 'string';
|
|
81
|
+
})
|
|
82
|
+
.map((block) => block.text)
|
|
83
|
+
.join('\n');
|
|
84
|
+
if (text.length === 0)
|
|
85
|
+
return null;
|
|
86
|
+
try {
|
|
87
|
+
return JSON.parse(text);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return text;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/** Raised when a `group verb` pair matches no tool, with the nearest group named. */
|
|
94
|
+
export function unknownCommand(command, groups) {
|
|
95
|
+
return new CliError(`Unknown command "kairon ${command}".`, groups.length > 0 ? 'kairon --help' : 'kairon login, then kairon --help');
|
|
96
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { openBrowser } from '../browser.js';
|
|
2
|
+
import { configPath, saveCredentials } from '../config.js';
|
|
3
|
+
import { CliError } from '../errors.js';
|
|
4
|
+
import { discoverAuthServer, scopesToRequest } from '../oauth/discovery.js';
|
|
5
|
+
import { startLoopback } from '../oauth/loopback.js';
|
|
6
|
+
import { createPkcePair, createState } from '../oauth/pkce.js';
|
|
7
|
+
import { registerClient } from '../oauth/register.js';
|
|
8
|
+
import { authorizeUrl, exchangeCode } from '../oauth/tokens.js';
|
|
9
|
+
/** How long to wait for the human to finish in the browser before giving up. */
|
|
10
|
+
const LOGIN_TIMEOUT_MS = 5 * 60_000;
|
|
11
|
+
/**
|
|
12
|
+
* `kairon login` — OAuth 2.1 authorization code + PKCE on a loopback redirect (MR47).
|
|
13
|
+
*
|
|
14
|
+
* The order below is not interchangeable. The port must be bound before dynamic
|
|
15
|
+
* client registration, because the redirect URI we register has to be the one the
|
|
16
|
+
* browser will actually be sent to; and the listener must be waiting before the
|
|
17
|
+
* browser opens, because on a fast machine the redirect can arrive before an
|
|
18
|
+
* `await` that came later would have run.
|
|
19
|
+
*/
|
|
20
|
+
export async function login(server, out = process.stdout) {
|
|
21
|
+
const metadata = await discoverAuthServer(server);
|
|
22
|
+
const scope = scopesToRequest(metadata);
|
|
23
|
+
const state = createState();
|
|
24
|
+
const pkce = createPkcePair();
|
|
25
|
+
const listener = await startLoopback(state);
|
|
26
|
+
try {
|
|
27
|
+
const clientId = await registerClient(metadata, listener.redirectUri, scope);
|
|
28
|
+
const url = authorizeUrl(metadata, {
|
|
29
|
+
clientId,
|
|
30
|
+
redirectUri: listener.redirectUri,
|
|
31
|
+
challenge: pkce.challenge,
|
|
32
|
+
state,
|
|
33
|
+
scope,
|
|
34
|
+
});
|
|
35
|
+
out.write(`Opening your browser to sign in to ${server}\n`);
|
|
36
|
+
out.write(`If it does not open, paste this into your browser:\n\n${url}\n\n`);
|
|
37
|
+
openBrowser(url);
|
|
38
|
+
const code = await withTimeout(listener.waitForCode(), LOGIN_TIMEOUT_MS);
|
|
39
|
+
const credentials = await exchangeCode(metadata, {
|
|
40
|
+
code,
|
|
41
|
+
verifier: pkce.verifier,
|
|
42
|
+
clientId,
|
|
43
|
+
redirectUri: listener.redirectUri,
|
|
44
|
+
});
|
|
45
|
+
await saveCredentials(server, credentials);
|
|
46
|
+
out.write(`Signed in to ${server}. Credentials saved to ${configPath()}\n`);
|
|
47
|
+
if (!credentials.refreshToken) {
|
|
48
|
+
// Worth saying out loud: without a refresh token the user gets sent back
|
|
49
|
+
// through the browser when the access token expires, and silently
|
|
50
|
+
// discovering that in a month is worse than one line now.
|
|
51
|
+
out.write('Note: the server issued no refresh token, so you will need to sign in again when this one expires.\n');
|
|
52
|
+
}
|
|
53
|
+
return credentials;
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
// Always give the port back, including on Ctrl-C partway through: a leaked
|
|
57
|
+
// listener would hold a port for the life of the process for nothing.
|
|
58
|
+
listener.close();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function withTimeout(promise, ms) {
|
|
62
|
+
return new Promise((resolve, reject) => {
|
|
63
|
+
const timer = setTimeout(() => {
|
|
64
|
+
reject(new CliError('Timed out waiting for the browser sign-in.', 'kairon login'));
|
|
65
|
+
}, ms);
|
|
66
|
+
// `unref` so a finished login exits immediately instead of idling until the
|
|
67
|
+
// timeout fires.
|
|
68
|
+
timer.unref?.();
|
|
69
|
+
promise.then((value) => {
|
|
70
|
+
clearTimeout(timer);
|
|
71
|
+
resolve(value);
|
|
72
|
+
}, (error) => {
|
|
73
|
+
clearTimeout(timer);
|
|
74
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { clearCredentials } from '../config.js';
|
|
2
|
+
/**
|
|
3
|
+
* `kairon logout` — remove the stored credentials for one server (MR48).
|
|
4
|
+
*
|
|
5
|
+
* Local only, and the message says so, because this deployment publishes no
|
|
6
|
+
* revocation endpoint (see `oauth/discovery.ts`): the access token stays valid on
|
|
7
|
+
* the server until it expires. Printing a bare "Logged out" would imply a
|
|
8
|
+
* server-side revocation that did not happen, which is the kind of small lie that
|
|
9
|
+
* matters on a shared machine.
|
|
10
|
+
*/
|
|
11
|
+
export async function logout(server, out = process.stdout) {
|
|
12
|
+
const removed = await clearCredentials(server);
|
|
13
|
+
if (removed) {
|
|
14
|
+
out.write(`Removed stored credentials for ${server}.\n`);
|
|
15
|
+
out.write('The access token stays valid on the server until it expires.\n');
|
|
16
|
+
}
|
|
17
|
+
else {
|
|
18
|
+
out.write(`No stored credentials for ${server}.\n`);
|
|
19
|
+
}
|
|
20
|
+
return removed;
|
|
21
|
+
}
|